iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Vibe Coding

轉生到全端工程師沒多久就要負責公司的大平台??系列 第 19

Day 19|出境守門:可遮蔽 vs 必攔截

  • 分享至 

  • xImage
  •  

昨天 Day 18 把縱深防禦那條防線整個畫出來時,最後一格我刻意留了個尾巴:模型生成回答之後,還有一道「出境 Guardrail」。前面三天(Day 16 到 Day 18)談的都是入境——攻擊者塞進來的注入、行員打進來的個資,都在「進門」這一側被攔被遮。但有件事我們一直還沒正面回答:模型自己吐出來的那串字,難道就一定乾淨嗎?今天就把目光從入境轉到出境。而且出境這道關有個入境沒有的分岔——同一道關,面對不同性質的風險,會做出兩種完全不同的處置:有些遮一遮就放行,有些則非整則攔下不可。這就是標題說的可遮蔽 vs 必攔截

本篇結構:

  • 為什麼模型的輸出也要過關
  • 同一道關,兩種處置:判準是「能不能靠局部處理化解」
  • 被擋,不等於壞掉:一律回 HTTP 200 加旗標
  • 一道關兩種策略,貴在判得準

為什麼模型的輸出也要過關

直覺上會覺得,入境都已經把個資遮成 *** 了,送進模型的提問乾乾淨淨,那模型回出來的東西應該也乾淨才對。這個直覺有兩個漏洞。

第一個漏洞是模型會「自己長出」個資。 它不是只會把你給它的東西原樣吐回來——它會生成。RAG 把知識庫的片段拼進上下文(這條檢索鏈 Day 22 會細講),那些片段裡可能就含著某位行員的 Email、某張工單上的手機號碼;模型在組答案時,很可能把這些順手寫進回覆。換句話說,個資的來源不只有一處:

  • 使用者這次的輸入——入境那道關盯的就是這條。
  • 模型生成的內容——模型在生成階段自己寫出來的。
  • 檢索回來的資料——RAG 從知識庫拼進上下文的片段。

入境那道關只盯著第一條,管不到後面那兩條。所以即使提問端遮得再乾淨,出境端仍可能冒出新的個資,得再遮一次。

第二個漏洞性質完全不同,是入境根本不會出現的風險:不當內容。 暴力、仇恨、騷擾這類有毒輸出,是模型在生成階段才可能產出的東西——使用者的提問裡未必有,是模型「答」出來的。這類風險在入境守門那幾天從沒提過,因為它本來就不屬於入境的職責範圍。Day 16 講過一個相關的道理:凡是要對行員負責、要留得下稽核證據的判斷,Portal 就得自己也把關一道,不能全寄望模型供應商的審查——那是我們看不見、也控制不住的。出境守門對不當內容的攔截,正是這個原則在輸出端的落實。

把這兩個漏洞放在一起,出境守門要處理的就是兩種性質迥異的東西:一種是個資(可能是回吐、也可能是模型新生成的),一種是不當內容。而它們需要的處置,恰好不一樣——這正是出境這道關最值得拆開講的設計。

同一道關,兩種處置:判準是「能不能靠局部處理化解」

出境守門做決策時,心裡其實只問一個問題:這個風險,能不能靠局部處理就化解掉? 答案決定了走哪條路。

先看個資。一段回覆裡夾著一支手機號碼,這個風險是「局部的」——它就侷限在那幾個字元,把那幾個字元遮掉,整段回覆其餘部分完全無害。所以個資走的是 mask-and-pass:遮蔽後仍回傳——「遮蔽」說的是處置策略,實際動手遮的動作,用的仍是 Day 17 那套遮罩。使用者要的是答案,我們沒有理由因為答案裡有一支號碼就整段扣住不給,只要把敏感片段隱去就好。這跟 Day 17 入境遮罩用的是同一套邏輯、同一套規則——八類 PII(個資)、同樣「先具特異性、後泛用」的套用順序(細節見 Day 17)。Day 18 特別強調過:入境、出境、送 LLM 前那幾道關共用同一支偵測元件,免得各寫各的、規則漂移。

再看不當內容。一段回覆裡帶著仇恨言論,這個風險是「全域的」——你沒辦法把「仇恨」這件事框成某幾個字元然後遮掉。就算硬把幾個刺眼的詞打上馬賽克,剩下半截仍然是一段不該出現的回覆,遮一半等於沒遮。這類風險無法靠局部處理化解,所以走的是整則攔截:這則回答整個不送,回給使用者的是一個「內容被擋下」的旗標,而不是一段挖了洞的答案。

把兩條路並排對照,差別就清楚了:

維度 個資(PII) 不當內容
風險範圍 局部——侷限在那幾個字元 全域——瀰漫整段回覆
能否局部化解 不能
處置策略 mask-and-pass(遮蔽後放行) 整則攔截(不送)
使用者拿到的東西 (遮過的)答案 「內容被擋」提示
對應 status success blocked
慣用供應商 regex(本地規則式) openai-moderation(內容分類器)

把兩條路畫在一起,出境守門的決策大概長這樣:

        [ LLM 生成的回答 ]
                │
                ▼
        ┌───────────────┐
        │  出境 Guardrail │
        └───────────────┘
                │
       ┌────────┴─────────┐
       ▼                  ▼
   含 PII?             含不當內容?
 (局部風險)          (全域風險)
       │                  │
       ▼                  ▼
  遮掉那幾個字元       整則扣住不送
  其餘照常回傳          回 blocked 旗標
  = mask-and-pass      = 必攔截
       │                  │
       ▼                  ▼
  使用者拿到(遮過的)答案   使用者拿到「內容被擋」提示

要注意這兩條路並非互斥的供應商選擇——同一道關會依當下偵測到的風險性質自己分流。實務上跟供應商的接法是這樣:

  • 若出境用的是純本地的規則式遮罩(regex),它做的就是 PII 遮蔽那條路。
  • 若接的是內容分類器(openai-moderation),它會把回覆送去判類別,命中 hateviolence 這些違規類別就走整則攔截那條路。

兩家供應商剛好各自擅長一條路,但「遮 vs 擋」的判準本身是固定的:能局部化解的就遮、不能的就擋。 判準固定,但攔截那條路跑不跑得起來,取決於接的是哪家供應商,文末會交代。

config 上,出境 Guardrail 跟入境沿用 Day 12 那套宣告式抽換、共用同一個 provider 設定,差別只在它擺的位置(生成之後)和它面對的輸入(模型回覆而非使用者提問):

portal:
  guardrail:
    provider: openai-moderation   # mock | regex | openai-moderation

被擋,不等於壞掉:一律回 HTTP 200 加旗標

出境守門最後留下的一筆,是它怎麼把「被攔」這件事告訴前端。這裡有個 Portal 從頭到尾都守的約定,值得單獨拎出來講:被擋下的請求,一律回 HTTP 200,外加一個狀態旗標,而不是回 4xx5xx 的系統錯誤。

為什麼這麼在意這件事?因為「內容被守門擋下」和「系統壞了」是兩件根本不同的事,不該共用同一個訊號。一段回答因為含不當內容被攔,這是守門正常運作的結果——伺服器運作得好好的、使用者的請求也合法地走完了整條鏈,只是生出來的答案沒通過內容審查而已。如果這時候回 500,前端的錯誤處理會把它當成「Portal 掛了」,可能觸發重試、可能跳出一個嚇人的系統錯誤頁,反而模糊了真正的訊息:你的問題收到了、處理了,只是這個答案我們不能給你。反過來用 HTTP 200 加旗標,前端就能清楚分辨「成功」「被擋」「真的出錯」這三種結果,給使用者一段得體的中性提示,而不是一個紅色的崩潰畫面。

回想 Day 9 講 correlation id 那天我們提過,一次對話的結局有三種,它們是平行的、不一樣的結局:

status 含義 前端呈現 HTTP
success 處理成功(含 PII 遮蔽後放行) 正常顯示答案 200
blocked 被守門擋下 中性提示 200
error 系統真的出錯 錯誤頁 4xx5xx

出境守門的攔截,落在 blocked 這一格。對照一筆被攔的回覆,狀態大致是這樣:

{
  "requestId": "a1b2c3d4",
  "status": "blocked",
  "blockedStage": "output-guardrail",
  "blockedReason": "moderation:hate",
  "answer": "這個問題我無法提供回覆,請換個方式詢問或聯繫人工窗口。"
}

statusblocked 而不是 error,這個區分會一路傳下去:前端據此顯示中性提示而非錯誤頁,稽核紀錄(audit log)也據此把這筆記成「被擋」而非「失敗」(Day 24 講 audit 時會看到 status 同樣有 success | blocked | error 三態)。HTTP 層面,整個回應仍是 200——對 HTTP 來說,「我成功地判斷出這則內容該擋」本來就是一次成功的處理。循著 req=a1b2c3d4 也能在日誌把這次攔截串起來:

2026-06-22 09:15:03.640  INFO [req=a1b2c3d4] Guardrail : output mask-and-pass pii=[PHONE]
2026-06-22 09:15:03.642  INFO [req=a1b2c3d4] Guardrail : output blocked category=hate
2026-06-22 09:15:03.643  INFO [req=a1b2c3d4] AuditLog  : emit status=blocked durationMs=231

頭一行是遮蔽那條路(個資遮了、答案照送),第二行是攔截那條路(整則擋下)——同一道關的兩種處置,在日誌上一眼就能分辨。同樣的對照也適用於使用者體驗:個資被遮的那種情況,status 其實是 success,因為答案照常送出去了,只是敏感片段變成了 ***,使用者根本不會察覺有東西被遮過。被遮,和被擋,在使用者那端是天差地遠的兩件事(前者拿到答案,後者拿到一段提示),而這個差別的源頭,就是前面那個「風險能不能局部化解」的判準。

一道關兩種策略,貴在判得準

把今天收束一下。出境守門最特別的地方,不在於它「又遮了一次 PII」——那只是把 Day 17 的遮罩邏輯搬到輸出端複用而已。它真正的設計重點,是在同一道關裡放了兩種處置策略,並且用一個清楚的判準去分流:風險能靠局部處理化解的,遮蔽後放行;不能的,整則攔截。

換個角度看,這其實是把 Day 10 那套「依風險量級分別設定」的不對稱思路,搬到了同一道關的內部:

不對稱的層次 出處 寬的一邊 嚴的一邊
不同操作之間 Day 10 提問 fail-open 改設定 fail-closed
同一道關內,不同風險 今天 PII(遮了就放) 不當內容(一律攔)

安全設計不是「越嚴越好」,而是「每種風險配一種剛好夠的處置」:對局部可控的風險上重手是擾民,對瀰漫性的風險手軟則是失職。

這個設計的代價,是它把判斷的準確度押在了偵測能力上。兩條路的風險其實不對稱:

  • 遮蔽那條路相對安全——就算 PII 偵測漏了一個格式,頂多是漏遮,不至於把整則答案錯殺(誤遮的代價 Day 17 講過,是另一回事);何況 Day 18 講的縱深防禦還有 audit 保底那一層接著,寫進日誌前會再遮一次。
  • 攔截那條路就敏感得多——整則扣住是個重手的處置,內容分類器要是誤判,使用者要的正常答案就被無辜攔掉了。

這也是為什麼「必攔截」這條路我們只交給內容分類器這種專門判有毒內容的供應商,而不拿規則式比對硬湊——遮錯了還能補救,擋錯了使用者就直接拿不到答案。把重手的處置交給更可信的判斷者,是這道關刻意的分工。

最後誠實標一條邊界:

能不能攔下語意層的不當內容,取決於你裝的是哪一檔 guardrail。離線 demo 常用的 regex 只會遮 PII,攔不了仇恨暴力那類語意風險,真要把「整則攔截」這條路徑跑起來,得接上 openai-moderation 這種帶內容分類器的供應商。所以這道關的「兩種處置」並非任何設定下都同時生效——遮蔽幾乎隨時在、攔截則看你接了誰。

出境守門從不被當成「最後一道保險」來設計,它只是縱深防禦裡的其中一層,後面還有 audit 保底接著。每一層守好自己出口的那份東西、不假設別人一定守住,這個 Day 18 立下的原則,在出境這裡同樣成立。

明天 Day 20,我們轉去看一種更刁鑽的風險——當模型不只是「回答」、而是能自主決定去呼叫工具查資料時,攻擊者會用話術誘騙它「代查別人的記憶」越權行事。這道專防 confused deputy(代理混淆)的閘該怎麼設?就看 Portal 怎麼在工具呼叫的邊界,把操作身分強制鎖回登入者本人。


上一篇
Day 18|縱深防禦
下一篇
Day 20|confused deputy 防護
系列文
轉生到全端工程師沒多久就要負責公司的大平台??21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言